現在讓我們對元件進行單元測試吧
單元測試的原則為「把待測物當成黑盒子,專注於測試公開介面」,也就是說我們只會針對元件的 template、props、event、對外公開的 method 與屬性,不會測試元件內部的私有屬性和邏輯。
這是因為即使元件內部程式隨著時間變更,只要公開介面維持一致,都能保證測試過關,才不會讓測試本身變得過於脆弱、難以維護。
推薦大家看看這個很棒的演講,還有 Vue Test Utils 的文件。
讓我們開始寫測試案例吧!( ´ ▽ ` )ノ
第一步讓我們安裝測試工具。
npm i -D @vue/test-utils @vitest/ui
接著新增第一個測試。
src\components\btn-naughty\btn-naughty.spec.ts
import { mount } from '@vue/test-utils';
import { test, expect } from 'vitest';
import BtnNaughty from './btn-naughty.vue';
test('第一個測試', () => {
  const wrapper = mount(BtnNaughty);
  expect(wrapper).toBeDefined();
})
現在讓我們使用 vitest 執行測試,新增測試用的腳本。
package.json
{
  ...
  "scripts": {
    ...
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  },
  ...
}
執行命令。
npm run test:ui
沒意外的話會開啟網頁,呈現以下畫面。

恭喜我們成功執行第一個測試了!ヾ(◍'౪`◍)ノ゙
現在讓我們依照元件的公開介面,依序新增各個測試案例吧。
src\components\btn-naughty\btn-naughty.spec.ts
...
test('設定 label', async () => {
  const wrapper = mount(BtnNaughty);
  const label = '很長很長的 label'
  expect(wrapper.text()).not.toBe(label);
  await wrapper.setProps({ label });
  expect(wrapper.text()).toBe(label);
})
鱈魚:「然後就會發現測試完美的失敗啦!◝(≧∀≦)◟」
路人:「是在驕傲個甚麼鬼?Σ(ˊДˋ;)」
可以在終端機看到詳細錯誤訊息。
FAIL  src/components/btn-naughty/btn-naughty.spec.ts > 設定 label
AssertionError: expected '我是按鈕' to be '很長很長的 label' // Object.is equality
- Expected
+ Received
- 很長很長的 label
+ 我是按鈕
 ❯ src/components/btn-naughty/btn-naughty.spec.ts:14:26
     12| 
     13|   wrapper.setProps({ label });
     14|   expect(wrapper.text()).toBe(label);
       |                          ^
     15| })
     16| 
這是因為我們的元件根本沒有實作顯示 label 功能,讓我們修正一下這個 Bug 吧。(´,,•ω•,,)
src\components\btn-naughty\btn-naughty.vue
<template>
  <!-- 容器 -->
  <div class="relative">
    ...
    <!-- 按鈕容器 -->
    <div ... >
      <slot v-bind="attrs">
        <button class="btn">
          {{ props.label }}
        </button>
      </slot>
    </div>
  </div>
</template>
<script setup lang="ts">
...
const props = withDefaults(defineProps<Props>(), {
  label: '我是按鈕',
  ...
});
...
</script>
...
按下儲存的那一刻,會發現 vitest 已經執行完成了,這次完美通過了!(/≧▽≦)/
 RERUN  src/components/btn-naughty/btn-naughty.vue x17
 ✓ src/components/btn-naughty/btn-naughty.spec.ts (1)
   ✓ 設定 label
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  22:35:38
   Duration  225ms
追加一些公開參數讓測試更方便進行。
src\components\btn-naughty\btn-naughty.vue
...
<script setup lang="ts">
...
// #region Methods
defineExpose({
  /** 按鈕目前偏移量 */
  offset: carrierOffset,
});
// #endregion Methods
</script>
...
現在讓我們追加更多的測試案例吧。ԅ(´∀` ԅ)
src\components\btn-naughty\btn-naughty.spec.ts
...
test('設定 zIndex', async () => {
  const zIndex = 9999;
  const wrapper = mount(BtnNaughty, {
    props: { zIndex }
  });
  const carrierEl = wrapper.find('.carrier').element;
  if (!(carrierEl instanceof HTMLElement)) {
    throw new Error('carrierEl 不是 HTMLElement');
  }
  expect(carrierEl.style.zIndex).toBe(zIndex.toString());
})
test('設定 maxDistanceMultiple', async () => {
  const wrapper = mount(BtnNaughty, {
    props: { maxDistanceMultiple: 1, }
  });
  // 由於最大距離是 1,所以觸發兩次後一定會超出範圍,導致返回原點
  await wrapper.find('button').trigger('click');
  await wrapper.find('button').trigger('click');
  expect(wrapper.vm.offset.x).toBe(0);
  expect(wrapper.vm.offset.y).toBe(0);
})
test('disabled 後,觸發 click 會移動', async () => {
  const wrapper = mount(BtnNaughty);
  await wrapper.find('button').trigger('click');
  // 未 disabled 時,應該有 click 事件
  expect(wrapper.emitted()).toHaveProperty('click');
  expect(wrapper.vm.offset.x).toBe(0);
  expect(wrapper.vm.offset.y).toBe(0);
  await wrapper.setProps({ disabled: true });
  await wrapper.find('button').trigger('click');
  // disabled 時,應該有 run 事件
  expect(wrapper.emitted()).toHaveProperty('run');
  // 而且會產生偏移
  expect(wrapper.vm.offset.x).not.toBe(0);
  expect(wrapper.vm.offset.y).not.toBe(0);
})
test('default slot 可修改按鈕 HTML 內容', async () => {
  const wrapper = mount(BtnNaughty, {
    slots: {
      default: '<span class="btn">按我</span>',
    }
  });
  // 預設的 button 不應該存在
  expect(wrapper.find('button').exists()).toBe(false);
  const target = wrapper.find('span');
  expect(target.exists()).toBe(true);
  expect(target.classes()).includes('btn');
})
test('rubbing slot 可修改拓印 HTML 內容', async () => {
  const wrapper = mount(BtnNaughty, {
    slots: {
      rubbing: '<span class="rubbing">拓印</span>',
    }
  });
  // 預設的 button 應該存在
  expect(wrapper.find('button').exists()).toBe(true);
  const target = wrapper.find('span');
  expect(target.exists()).toBe(true);
  expect(target.classes()).includes('rubbing');
})
順利通過!✧*。٩(ˊᗜˋ*)و✧*。
 RERUN  src/components/btn-naughty/btn-naughty.spec.ts x71
 ✓ src/components/btn-naughty/btn-naughty.spec.ts (6)
   ✓ 設定 label
   ✓ 設定 zIndex
   ✓ 設定 maxDistanceMultiple
   ✓ disabled 後,觸發 click 會移動
   ✓ default slot 可修改按鈕 HTML 內容
   ✓ rubbing slot 可修改拓印 HTML 內容
 Test Files  1 passed (1)
      Tests  6 passed (6)
   Start at  00:28:58
   Duration  283ms
不過這不代表以上測試已經覆蓋了所有情境,大家還可以想想看有甚麼測試案例,說不定還會發現隱藏的 Bug 喔。( ´ ▽ ` )ノ
以上程式碼已同步至 GitLab,大家可以前往下載: